Day 17 學完計時器之後,我的結論是:動畫和計時器用在表現層可以,用在規則層會破壞回合制。當時我把「受擊震動」記下來,留到敵人會反擊的那天再做。
今天就是那天。
Day 24 讓小怪會反擊之後,一個回合裡其實發生了兩件事:勇者打小怪、小怪打勇者。但畫面上只看到兩個數字同時變。
玩家看不出剛剛是誰被打了。
震動要解決的就是這個 —— 它是功能性回饋,不是特效。
@keyframes shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-20px); }
30% { transform: translateX(16px); }
45% { transform: translateX(-12px); }
60% { transform: translateX(8px); }
75% { transform: translateX(-4px); }
}
.fighter.shake {
animation: shake 0.35s ease-in-out;
}
@keyframes 是動畫的劇本,百分比代表「播到幾成的時候長什麼樣」。幅度我刻意讓它越來越小,模擬被打之後的餘震。
用 transform: translateX() 而不是改 left,是因為 transform 不會影響版面計算,瀏覽器跑起來比較順。
踩到一個坑:transform 對 inline 元素完全無效。血量數字是 <span>(預設就是 inline),所以要先給它 display: inline-block 才動得起來。這個又是那種「不報錯、就是沒反應」的安靜失敗。
這是我原本沒想到的問題。
enemyBox.classList.add("shake"); // 第一下:動了
enemyBox.classList.add("shake"); // 第二下:什麼都沒發生
因為元素已經有這個 class 了,再加一次對 CSS 來說「狀態沒變」,動畫不會重新開始。連續攻擊時只有第一下看得到效果。
標準解法是「先移除 → 強迫重繪 → 再加上」:
function playEffect(el, className) {
el.classList.remove(className);
void el.offsetWidth; // ← 關鍵的一行
el.classList.add(className);
}
中間那行看起來完全莫名其妙:讀取一個寬度,然後把結果丟掉(void 的意思就是「執行它,但不要這個值」)。
它的作用是逼瀏覽器把前面的移除動作真的算完。
瀏覽器為了效能,會把連續的樣式修改累積起來一次處理。如果沒有這一行,remove 和 add 會被合併成「什麼都沒變」。而「讀取版面資訊」(寬度、高度、位置)會強迫它立刻結算 —— 因為它必須先算完才能給你正確的數字。
這個技巧叫強制重排(force reflow),很多動畫函式庫內部都在用。
實測結果:連續快速點擊,每一下都有震動,不會只震第一下。
做完之後我打了幾回合,發現:兩邊的血條一起震動,根本看不出攻擊順序。
原因很清楚:兩次震動都在同一個瞬間被觸發。整段 attack() 是同步執行的,傷害計算、血量更新、兩次動畫啟動,全部在同一毫秒內完成。
但回合制的本質是「你打我、我打你」,它有先後。 畫面卻把它壓縮成同一瞬間 —— 資訊在,順序不見了。
function showHit(box, hpTextEl, delay = 0) {
if (delay === 0) {
playEffect(box, "shake");
playEffect(hpTextEl, "flash");
return;
}
setTimeout(() => {
playEffect(box, "shake");
playEffect(hpTextEl, "flash");
}, delay);
}
showHit(enemyBox, enemyHpText); // 小怪先震
showHit(heroBox, heroHpText, 380); // 勇者 0.38 秒後才震
而這正好是 Day 17 那個判斷的完美應用:
| 計時器用在哪 | 可不可以 |
|---|---|
| 規則層(延遲才算傷害) | 不行,那就變成即時制了 |
| 表現層(傷害早就算完,只是動畫晚播) | 可以 |
傷害、血量、勝負在我點下去的那一瞬間就全部算完了。 計時器只負責演出的節奏 —— 玩家的操作沒變、遊戲的規則沒變、結果沒變,只有「看起來的先後」變了。
Day 17 我說「知道它能做什麼,然後決定不用」。今天發現不是不用,是用在對的地方。同一個工具,放在規則裡會毀掉遊戲,放在表現裡卻剛好補上了遊戲缺的那塊。
說明裡我留了一個測試:把震動幅度調大看看。
我把幅度改成 -500px,結果整個超好笑 —— 它震出畫面了。
而且不只是視覺上跑掉:元素滑到畫面外之後,瀏覽器判斷「有東西超出範圍」,底下就跑出一條水平捲軸。動畫本身沒有錯,但它把版面撐壞了。
修法很簡單:
body {
overflow-x: hidden; /* 溢出畫面的部分切掉,不要生出捲軸 */
}
這件事很值得記下來:動畫不是獨立存在的,它會影響版面。我在測試工作上看過類似的問題 —— 某個動畫元件單獨看很正常,放進頁面就把排版擠歪了。
至於幅度,試過兩個極端之後我把最大位移定在 20px 左右。原本的 8px 太含蓄,幾乎注意不到;500px 當然是鬧著玩的。20px 大概是「一眼就看得到被打了,但不會覺得畫面在鬧脾氣」的程度。
這種數字沒有標準答案,只能自己看 —— 跟 Day 24 調攻擊力一樣,推不出來,要試。差別是那邊可以跑模擬驗證,這邊只能靠眼睛。
做完延遲之後我立刻發現下一個問題:勇者的血條瞬間就掉了,震動卻 0.38 秒後才來。 看起來像兩件無關的事。
所以要延後的不只是震動,而是「反擊」這件事的所有呈現:血條、數字、震動、訊息,要一起晚。
我把畫面更新拆成兩半:
function renderHero() { renderFighter(state.hero, ...); }
function renderEnemy() { renderFighter(state.enemy, ...); }
以前一律呼叫 updateScreen() 把兩邊一起更新,但一來一往的演出需要分開更新 —— 小怪被打時只動小怪,勇者被打時只動勇者。
原本的訊息區只有一行,每次都被覆蓋 —— 你永遠只看得到最後一句。既然現在一個回合會產生兩則訊息,乾脆改成真正的戰鬥紀錄:
function addLog(html, className = "", showTurn = false) {
const line = document.createElement("div");
line.className = "log-line" + (className ? " " + className : "");
line.innerHTML = (showTurn ? `<span class="log-turn">[${state.turn}]</span>` : "") + html;
logText.appendChild(line);
// 鞭屍可以按無限次,只留最近 200 行,免得越積越多
while (logText.children.length > 200) {
logText.removeChild(logText.firstChild);
}
logText.scrollTop = logText.scrollHeight; // 自動捲到最底
}
document.createElement 和 appendChild 是 Day 15 提過但沒真的用上的東西 —— 造一個新元素、掛進去,今天終於派上用場。
改完之後一場戰鬥長這樣:
勇者 小明 出征!
[1] 小明 造成 12 點傷害!
小怪 反擊 9 點!
[2] 小明 造成 8 點傷害!
小怪 反擊 11 點!
...
小怪 被擊敗了!(共 10 回合)
一來一往變成兩行,回合標記只標一次。而且驗證失敗的訊息也進紀錄,不會再把戰鬥內容洗掉。
改完之後我測了一下,結果紀錄裡出現這個:
小怪 被擊敗了!(共 11 回合) ← 小怪已經死了
小怪 反擊 11 點! ← 屍體打人
勇者請不要對小怪屍體動手!(第 1 下)
小怪 反擊 9 點! ← 屍體又打人
勇者 倒下了…… 撐了 11 回合 ← 勇者被屍體打死了
兩邊都是 0 血。我贏了之後又輸了。

原因是連點。當我在 0.38 秒內又按一次:
第 11 回合:攻擊 → 小怪沒死 → 排一個 380 毫秒後的反擊演出 ⏳
(還沒到 380 毫秒,我又點了)
下一次點擊:攻擊 → 小怪死了 → endGame("win") → 結束
(380 毫秒到了)
⏳ 排隊中的那個反擊演出執行了 → 屍體反擊
那個 setTimeout 是在小怪還活著的時候排的,它不知道後來發生了什麼事。
更糟的是「勇者死了沒」這個判斷也是排隊當下就算好的,所以它會拿過期的結果去呼叫 endGame("lose"),把已經是 "win" 的結局覆蓋掉。
這就是 Day 19 學到的非同步的陰暗面:程式不再是一條直線,排隊時的狀態和執行時的狀態可能已經不一樣了。
第一層:演出沒播完,不能開始下一回合。
第二層:重置時剪線。 如果打到一半按「再戰一次」,那個排隊中的演出會在新的一場跑出來亂噴訊息:
if (pendingTimer !== null) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
Day 17 學 clearInterval 的時候我只覺得「原來計時器要自己收尾」,今天才遇到真的非收不可的場合。
第一層防護我本來用 attackBtn.disabled = true,按鈕會變灰。但這樣玩家只知道「不能按」,不知道「為什麼」。
我想讓它回一句話,結果卡住了 —— disabled 的按鈕根本不會觸發點擊事件,程式收不到訊號,自然也沒機會回話。
所以改成用旗標擋,按鈕只在視覺上變淡:
if (isAnimating) {
addLog(`${hero.name}請不要偷襲!`, "log-warning");
return;
}
button.busy { opacity: 0.5; cursor: progress; }
現在連點會看到「勇者請不要偷襲!」,而不是一個沒反應的灰按鈕。
這件事讓我理出一個分別:
| 點擊事件 | 能不能解釋原因 | |
|---|---|---|
disabled = true |
收不到 | 只能讓按鈕變灰 |
| 旗標 + 視覺樣式 | 收得到 | 可以回話 |
「擋下操作」和「告訴使用者為什麼被擋」是兩件事,disabled 只做到第一件。
而我在「失敗」的時候還是用 disabled —— 因為那是永久狀態,不需要解釋「等一下就好了」;演出中是暫時狀態,值得說一句。
@media (prefers-reduced-motion: reduce) {
.fighter.shake,
.fighter-hp.flash,
.hp-fill {
animation: none;
transition: none;
}
}
有些人對畫面晃動敏感(暈眩、前庭功能異常、某些偏頭痛),作業系統有一個「減少動態效果」的設定。這段 CSS 的意思是:使用者開了那個設定,就把動畫全部關掉。
實測結果:血條變成瞬間減少,不再有滑動過渡,震動也完全消失。資訊照樣看得到,只是不動。 這就是對的行為 —— 無障礙不是「拿掉功能」,是「換一種方式傳達同樣的資訊」。
無障礙這件事,我在測試工作上還沒正式做過,但有個親身經驗:
我有同事有點色盲,所以我們的專案管理工具大家都開色弱模式 —— 不同顏色的標籤會帶不同的符號。
這裡有兩件事我覺得做得好。第一是用符號補上顏色,第二是大家都開,而不是只有那位同事自己開 —— 這樣討論的時候看到的是同一套東西,不會出現「我說紅色那個」「哪個紅色?」的斷線。
這背後是無障礙設計的第一原則:不要只用顏色傳達資訊。
想到這裡我回頭檢查自己的遊戲。它用顏色傳達了不少狀態:血條綠/黃/紅、傷害數字紅色、背景配色。如果玩家是紅綠色盲,這些幾乎全部失效。
但檢查下來,關鍵資訊其實都有顏色以外的備援:
| 資訊 | 用了顏色 | 非顏色的線索 |
|---|---|---|
| 剩多少血 | 血條綠/黃/紅 | 數字 6 / 100 + 血條長度 |
| 已經死亡 | 文字變灰 | 刪除線 |
| 剛剛誰被打 | — | 震動 |
而今天做的震動,本身就是不依賴顏色的回饋 —— 它是動作。對色盲玩家、對開了色弱模式的使用者,效果完全一樣。
這不是我刻意設計的,是回頭檢查才發現的。但這也說明一件事:無障礙不一定要另外加功能,很多時候是確認「這個資訊有沒有第二種傳達方式」。
今天搞懂的事:
@keyframes + animation 做 CSS 動畫;transform 比改位置更順,但對 inline 元素無效void el.offsetWidth 強制重排、再 addoverflow-x: hidden 收掉createElement + appendChild 把訊息改成會累積的戰鬥紀錄clearTimeout 剪線disabled 的按鈕收不到點擊事件,所以沒辦法解釋「為什麼不能按」;暫時性的阻擋用旗標,永久性的才用 disabled
prefers-reduced-motion 尊重使用者的「減少動態效果」設定明天處理結果畫面,把勝利和失敗的收尾做完整。